40 缓存与风暴并存,谁说缓存风暴不可避免?

你好,我是温铭。

在前面缓存的那节课中,我为你介绍了,共享字典和 lru 缓存在高性能方面的一些优化技巧。其实,我们还遗留了一个非常重要的问题,也值得我们今天用单独的一节课来介绍,那就是“缓存风暴”。

什么是缓存风暴?

什么是缓存风暴呢?让我们先来设想下面这么一个场景。

数据源在 MySQL 数据库中,缓存的数据放在共享字典中,超时时间为 60 秒。在这 60 秒内的时间里,所有的请求都从缓存中获取数据,MySQL 没有任何的压力。但是,一旦到达 60 秒,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要触发查询数据源的函数,那么这些请求全部都将去查询 MySQL 数据库,直接造成数据库服务器卡顿,甚至卡死。

这种现象就叫做“缓存风暴”,它也有一个对应的英文名字Dog-Pile。很明显,我们之前出现的缓存相关的代码,都没有做过对应的处理。比如下面这段代码,就是有缓存风暴隐患的伪代码:

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

这段伪代码看上去逻辑都是正常的,你使用单元测试或者端对端测试,都不会触发缓存风暴。只有长时间的压力测试才会发现这个问题,每隔 60 秒的时间,数据库就会出现一次查询的峰值,非常有规律。不过,如果你这里的缓存失效时间设置得比较长,那么缓存风暴问题被发现的几率就会降低。

如何避免缓存风暴?

现在明白了什么是缓存风暴,我们的下一步就是要搞清楚如何避免它了。下面,让我们分为几个不同的情况来讨论一下。

主动更新缓存

在上面的伪代码中,缓存是被动更新的。只有在终端请求发现缓存失效时,它才会去数据库查询新的数据。那么,如果我们把缓存的更新,从被动改为主动,也就可以直接绕开缓存风暴的问题了。

在 OpenResty 中,我们可以这样来实现。首先,使用 ngx.timer.every 来创建一个定时任务,每分钟运行一次,去 MySQL 数据库中获取最新的数据,并放入共享字典中:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

然后,在终端请求的代码逻辑中,去掉查询 MySQL 的部分,只保留获取共享字典缓存的代码:

local value = get_from_cache(key)
return value

通过这样两段伪码的操作,缓存风暴的问题就被绕过去了。但这种方式也并非完美,因为这样的每一个缓存都要对应一个周期性的任务(OpenResty 中 timer 是有上限的,不能太多);而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据。

所以,在实际的项目中,我们一般都是使用加锁的方式来解决缓存风暴问题。接下来,我将为你讲解几种不同的加锁方式,你可以根据需要自行选择。

lua-resty-lock

我知道,一提到加锁,很多人可能会眉头一皱,觉得这是一个比较重的操作。而且,如果发生死锁了该怎么办呢?这显然还要处理不少异常情况。

但是,使用 OpenResty 中的 lua-resty-lock 这个库来加锁,这样的担心就大可不必了。lua-resty-lock 是 OpenResty 自带的 resty 库,它底层是基于共享字典,提供非阻塞的 lock API。我们先来看一个简单的示例:

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

因为 lua-resty-lock 是基于共享字典来实现的,所以我们需要事先声明 shdict 的名字和大小;然后,再使用 new 方法来新建 lock 对象。你可以看到,这段代码中,我们只传了第一个参数 shdict 的名字。其实, new 方法还有第二个参数,可以用来指定锁的过期时间、等待锁的超时时间等多个参数。不过这里,我们使用的是默认值,它们就是用来避免死锁等各种异常问题的。

接着,我们就可以调用 lock 方法尝试获取锁。如果成功获取到锁的话,那就可以保证只有一个请求去数据源更新数据;而如果因为锁已经被抢占、超时等导致加锁失败,那就需要从陈旧的缓存中获取数据,返回给终端。这个过程是不是听起来很熟悉?没错,这里就正好用到了我们上节课介绍过的的 get_stale API:

local elapsed, err = lock:lock("my_key")
# elapsed 为 nil 表示加锁失败,err的返回值是 timeout、 locked 中的一个
if not elapsed and err then 
    dict:get_stale("my_key")
end

如果 lock 成功,那么就可以安全地去查询数据库,并把结果更新到缓存中。最后,我们再调用 unlock 接口,把锁释放掉就可以了。

结合 lua-resty-lockget_stale,我们就完美地解决了缓存风暴的问题。在 lua-resty-lock 的文档中,给出了非常完整的处理代码,推荐你可以点击链接查看。

不过,每当遇到一些有趣的实现,我们总是希望能够看看它的源码是如何实现的,这也是开源的好处之一。这里,我们再深入一步,看看 lock 这个接口是如何加锁的,下面便是它的源码:

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

在共享字典章节中,我曾经提到过,shared dict 的所有 API 都是原子操作,不用担心出现竞争,所以用 shared dict 来标记锁的状态是个不错的主意。

这里 lock 接口的实现,便使用了 dict:add 接口来尝试设置 key。如果 key 在共享内存中不存在,add 接口就会返回成功,表示加锁成功;其他并发的请求走到 dict:add 这一行的代码逻辑时,就会返回失败,然后根据返回的 err 信息,选择是直接返回,还是多次重试。

lua-resty-shcache

不过,在上面 lua-resty-lock 的实现中,你需要自己来处理加锁、解锁、获取过期数据、重试、异常处理等各种问题,还是相当繁琐的。所以,这里我再给你介绍一个简单的封装:lua-resty-shcache

lua-resty-shcache是 CloudFlare 开源的一个 lua-resty 库,它在共享字典和外部存储之上,做了一层封装;并且额外提供了序列化和反序列化的接口,让你不用去关心上述的各种细节:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- cache good data for 10s
          negative_ttl = 3,            -- cache failed lookup for 3s
          name = 'my_cache',     -- "named" cache, useful for debug / report
        }
    )

    local my_table, from_cache = my_cache_table:load(key)

这段示例代码摘自官方的示例,不过,我已经把细节都隐藏了,方便你更好地把握重点。事实上,这个缓存封装的库并非是我们的最佳选择,但比较适合初学者去学习,所以我首先介绍的是它。在下一节课中,我会给你介绍其他的几个更好、更常用的封装,方便我们选择最合适的来使用。

Nginx 配置指令

另外,即使你没有使用 OpenResty 的 lua-resty 库,你也可以用 Nginx 的配置指令,来实现加锁和获取过期数据——即proxy_cache_lockproxy_cache_use_stale。不过,这里我并不推荐使用 Nginx 指令这种方式,它显然不够灵活,性能也比不上 Lua 代码。

写在最后

这节课,我主要为你介绍了缓存风暴和相应的几种应对方式。不得不说,缓存风暴,和之前我们反复提到的 race 问题一样,通过 code review 和测试都很难被发现,最好的方法还是提升我们本身的编码水平,或者使用封装好的类库来解决这类问题。

最后,给你留一个作业题。在你熟悉的语言和平台中,都是如何处理缓存风暴之类问题的呢?是否有比 OpenResty 更好的解决思想和方法呢?欢迎留言和我讨论这个问题,也欢迎你把这篇文章分享给你的同事朋友,一起学习和进步。